fix(bridge): fail fast when a request is discarded by a Messages relaunch - #199
Conversation
…unch A v2 request that disappears from the inbox without a reply can never be answered, but the poll loop kept waiting for it until the caller's full timeout. For sends that is 150s, during which the caller is blocked. `MessagesLauncher.launchInjectedMessages()` calls `cleanQueueDirectory()` on both queue directories, so relaunching Messages.app with the dylib wipes any request already in flight. If Messages.app dies mid-request — the keepalive then relaunches and reinjects — the original request is deleted and nothing will ever write its response file. The loop had no way to notice and polled on to the deadline. Detect it from the request's own on-disk state. A live request is either unclaimed (`<id>.json`) or claimed by the dylib (`<id>.processing.<pid>`, see processV2InboxFile). When neither exists and no reply has landed, the queue was cleared and the request is gone, so surface `.bridgeNotReady` immediately instead of stalling. The outbox is re-checked once before giving up, because the dylib removes its claim and writes the reply as separate steps and a reply can land between the two checks. An inbox that cannot be enumerated is treated as still-queued so a live request is never aborted by a transient read error. Beyond ending the stall, this distinguishes "discarded, definitely not delivered" from a plain timeout, which callers can safely retry — a timeout leaves delivery genuinely unknown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEKzjCwQqoMEbZwabsY1u6
|
@clawsweeper review |
|
🦞👀 Command router queued. I will update this comment with the next step. |
|
Codex review: needs maintainer review before merge. Reviewed August 2, 2026, 1:44 AM ET / 05:44 UTC. ClawSweeper reviewWhat this changesThe branch makes a Messages bridge request fail promptly when a relaunch clears its queued file, while treating every vanished request without a response as delivery-unknown rather than retry-safe. Merge readinessThis PR remains necessary: current main still waits only for a response file and can hold a discarded bridge request until its full timeout. The repaired head preserves the existing timeout error for every vanished request without a reply, resolving the earlier unsafe retry classification; no actionable patch finding remains. Priority: P1 Review scores
Verification
How this fits togetherThe iMessage bridge writes each CLI or gateway operation into a Messages-container inbox; the injected Messages helper claims it, performs the action, and writes a response into an outbox. The client poller returns that response to the caller, but a Messages relaunch can clear both queue directories while a request may be in flight. flowchart LR
A[CLI or gateway operation] --> B[Bridge client writes request]
B --> C[Messages bridge inbox]
C --> D[Injected Messages helper]
D --> E[Response outbox]
E --> F[Caller result]
G[Messages relaunch] --> H[Queue directories cleared]
H --> F
Before merge
Agent review detailsSecurityNone. Review metrics
Merge-risk optionsMaintainer options:
Technical reviewBest possible solution: Mark the repaired branch ready and merge it with the existing timeout contract intact, retaining the queue-state regression suite as protection for future bridge lifecycle changes. Do we have a high-confidence way to reproduce the issue? Yes. The PR supplies a real injected-Messages restart/fault-injection transcript, and source confirms that current main waits solely for the response file after a relaunch can remove the request. Is this the best way to solve the issue? Yes. The repaired head is the narrowest maintainable solution: detect a disappeared request to stop the stall, but return the existing delivery-unknown timeout rather than exposing a retry-safe or source-breaking new error case. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against e22dfad8e54e. LabelsLabel changes:
Label justifications:
EvidenceWhat I checked:
Likely related people:
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
HistoryReview history (20 earlier review cycles; latest 8 shown)
|
|
@clawsweeper re-review |
|
🦞👀 Command router queued. I will update this comment with the next step. |
Review raised that a vanished request can be reported as definitely-not- delivered even though the action may already have run. The stated mechanism (claim removed before the reply is written) is inverted — processV2InboxFile renames the reply into the outbox and only then drops the claim — but the concern is real through a different path: if the dylib dies after IMCore delivered and before the reply is published, the claim is orphaned and a later scan or relaunch clears it, exactly as that function's own comment describes. The defect was in the error semantics, not the ordering. Both situations collapsed into `.bridgeNotReady`, which the PR documents as retry-safe. Split them by tracking whether the dylib ever claimed the request: - never claimed, then absent -> nothing read it, so the action did not run. Still `.bridgeNotReady`, still retry-safe. - claimed, then absent with no reply -> the dylib had it and died mid-flight. New `.deliveryUnknown(action:)`, which callers must not retry blind. `requestStillQueued` becomes `requestQueueState` returning unclaimed/claimed/absent. An inbox that cannot be enumerated still reports unclaimed so a transient read error never ends a live request. Adds the interleaving test the review asked for, walking one request through unclaimed -> absent and another through unclaimed -> claimed -> absent, plus a guard that the two error cases stay distinct. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEKzjCwQqoMEbZwabsY1u6
|
Thanks — the finding pointed at a real problem, though not via the mechanism described. Fixed in On the stated ordering. The review says the producer "removes its processing claim and writes its reply as separate steps, leaving an interval where the action may already have run but the reply is not yet visible." The order is the opposite. In response = processV2Envelope(envelope); // action executes
...
[responseData writeToFile:tmp atomically:NO];
rename(tmp.UTF8String, outPath.UTF8String); // reply published (atomic)
// Drop the claimed request — we're done with it. If the process dies
// after claiming, a later inbox scan removes the orphan without
// replaying a potentially delivered side effect.
[[NSFileManager defaultManager] removeItemAtPath:claimPath error:nil];So "keep the processing claim until the reply is published" is already the invariant, and reordering would be a no-op. But the concern is valid through another path, which that comment names: if the dylib dies after IMCore delivered and before the reply is written, the claim is orphaned, and The defect was my error semantics, not the producer ordering: I collapsed two different situations into one retry-safe error. Fix. Track whether the dylib ever claimed the request:
Added the interleaving test that was asked for: one request walked through
Two things I did not change, deliberately:
|
|
@clawsweeper re-review |
|
🦞👀 Command router queued. I will update this comment with the next step. |
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
Note for anyone reading this PR: the review shown above is stale. It was produced against The P1 ("Keep the processing claim until the reply is published") was addressed in
State at Bot-side observation, in case it is useful to whoever maintains ClawSweeper: re-review requests are acking within seconds but not producing a review, and it only started after the head moved. Reviews ran fine at |
|
Maintainer repair is on the branch at The fast-failure mechanism is sound, but the original branch inferred retry safety from whether this client happened to observe a Proof on macOS arm64:
No message was sent and Messages.app was not relaunched during maintainer verification. |
Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
What Problem This Solves
Fixes an issue where a bridge request in flight when Messages.app restarts blocks the caller for the full timeout instead of failing. For sends that is 150s of a completely unresponsive caller, ending in a timeout for a request that was actually discarded seconds in.
Seen in production on an OpenClaw gateway: the agent's iMessage reply blocked for 150s and surfaced as a failure, even though the bridge had been repaired ~23s in and was healthy again long before the timeout expired.
Why This Change Was Made
MessagesLauncher.launchInjectedMessages()callscleanQueueDirectory()on both queue directories, so relaunching Messages.app with the dylib wipes any request already in flight. When Messages.app dies mid-request and the keepalive relaunches it, the original request file is deleted and nothing will ever write its response.invokeV2had no way to notice, so it polled the outbox until the deadline for a reply with no writer.The request's own on-disk state is a precise signal. A live request is either unclaimed (
<id>.json) or claimed by the dylib (<id>.processing.<pid>, set byprocessV2InboxFile). Neither present, with no reply, means the queue was cleared.Two details worth review:
Beyond ending the stall, this separates "discarded, definitely not delivered" (
.bridgeNotReady) from a plain timeout, where delivery is genuinely unknown. That distinction is what lets a caller retry safely: retrying after a timeout risks a duplicate, retrying a discarded request cannot.User Impact
A send issued while Messages.app is restarting now fails in about a poll interval instead of blocking for 150s, and reports a cause the caller can act on. Callers that today surface a hard failure to the user can distinguish a discarded request and retry it once the bridge is back.
Evidence
Pre-fix production incident. Timeline reconstructed from macOS unified logs and the gateway log:
loginwindow … appDeath for com.apple.MobileSMS— Messages.app diessend-messageissued; request written to the inboxprivate bridge unavailable; reinjectingAppSandbox,container_create_or_lookup) — queue directories wiped hereprivate bridge readyTimed out waiting for response to 'send-message'The bridge was healthy from 21:18:05, yet the request waited another ~2 minutes for a reply that could not arrive because its request file no longer existed. That is the window this change closes.
Tests (
Tests/IMsgCoreTests/IMsgBridgeClientQueueTests.swift) cover the three on-disk shapes the loop distinguishes, plus the fail-safe:<id>.json→ still queued<id>.processing.<pid>→ still queued (a live request must not be aborted)swift test: 489 tests in 4 suites, all passing.swift format lintclean on both touched files;swiftlintreports no violations in them;git diff --checkclean.Proof gap, stated plainly: the exact-path runtime evidence is the pre-fix incident. I have not killed Messages.app mid-send against the patched build — doing that on the production gateway would interrupt a live messaging channel. The discard path is covered deterministically by the tests above.
AI-assisted.
After-fix real behavior proof (added)
Run on macOS 26 against the real injected Messages.app, with the production gateway stopped first so its
imsg rpcreleased the bridge and the RPC queue had no competing client (verifiedimsg rpccount 0, inbox empty before the run).Fault injection.
SIGSTOPon the injected Messages process (verified stateT) so the dylib physically cannot drain the inbox, then the request is issued, then the process is killed andimsg launchrelaunches it — which is what invokescleanQueueDirectory()and wipes the in-flight request.Early failure — the changed path, patched build:
The call was made with
timeout: 150. Before this change it would have polled the full 150s and then reportedTimed out waiting for response to 'send-message'; instead it returns.bridgeNotReadyas soon as the request is observed to be gone. The elapsed time is dominated by client startup before the request is written; the wait ends promptly once the queue is wiped.Recovery — same call after the bridge is back:
The dylib answers in 67ms with its own parameter validation, confirming the bridge recovered and that the new queue-state check does not produce a false positive against a live bridge.
These were produced with a small throwaway executable calling
IMsgBridgeClient.invokedirectly, because each CLI subcommand wraps bridge errors in its own message (imsg sendfalls back to AppleScript and surfacesConnection is invalid (-609);imsg typingsurfaces an imagent error), which hides the underlyingIMsgBridgeError. The probe is not part of this PR — the branch contains only the two files above.Supporting CLI timings from the same isolated setup, showing the wait ending rather than running to the deadline:
imsg sendreturned 0.52s after its request was queued,imsg typing0.415s, and a post-recovery send succeeded in 0.64s.Environment restored: production LaunchAgent bootstrapped, listener on :18789, exactly one
imsg rpc, gateway healthok, iMessage channelrunningwithlastError: nullandreconnectAttempts: 0, and a live round-trip send verified.Remaining honesty note: the crash that starts this sequence is not addressed here. On the affected machine Messages.app dies on its own roughly every 2-3 days (6 message-tool stalls across 14 days of gateway logs); this change only stops an in-flight request from waiting out the full timeout when that happens.